iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Software Development

量化交易入門:從 K 線到可組合的交易策略引擎系列 第 13

"Day 12:同樣的價格,有時候很危險有時候沒事:用交易活躍度定義市場的節奏

  • 分享至 

  • xImage
  •  

12 顆 BTC 是多還是少

Day 09 那兩根 K 線,到今天第三次出場,因為它們正好卡在今天要問的問題上:

開盤時間(UTC) 成交量(BTC) 成交額(USDT) 成交筆數
2026-07-15 20:39 12.755 829,187 581
2026-07-15 07:46 12.883 832,009 2,426

成交量差 1%,成交筆數差 4.2 倍。

現在把問題往前推一步:12.755 顆 BTC 這個數字本身,算多還是算少?

它顯然不能單獨回答。同一天的最冷清那一分鐘成交 0.257 顆,最熱鬧那一分鐘成交 204.6 顆——12.755 落在中間偏低。但如果換成某個小幣,一分鐘成交 12 顆可能是它一整天的量。而就算還是 BTC,凌晨五點的 12 顆跟下午兩點的 12 顆也不是同一件事。

所以「活躍度」這種特徵不能輸出絕對值,要輸出相對於自己平常的程度。標準做法是 z-score:

z = (現在 − 平常) / 平常的標準差

聽起來很簡單,一行 pandas 就寫完了。但這一行有兩個地方寫錯不會報錯,而且兩個都會讓回測看起來比實際好。今天大半篇在處理那兩個地方,以及它們背後那個共同的形狀。

另外今天還要補一個 Day 24 會用到的東西:ATR。它跟活躍度是同一件事的另一面——活躍度量「多熱」,ATR 量「價格實際走了多少」,而它是決定停損距離的依據。

交易概念補課

波動率:價格實際走了多少

波動率(volatility)量的是價格變動的幅度,不是方向。

最常見的一種叫已實現波動率:把一段期間內每根 K 線的報酬變異累加起來,得到「這段時間價格總共抖了多少」。

實作上只有一個選擇要知道:報酬用對數報酬而不是百分比報酬。理由是對數報酬可加——把 1 分鐘的報酬換算成 1 小時的時候直接相加就對了,不會累積複利的偏差。這條在後面每一個換粒度的地方都成立,值得記住。

波動率跟成交量是兩個不同的東西,而它們會分家。兩種分家的方式都很常見:

  • 大量成交但價格不動:有人在某個價位持續吃貨或出貨,兩邊力道相當。成交量高、波動率低。
  • 少量成交但價格亂跳:沒有流動性,一張小單就把價格推走。成交量低、波動率高。

第二種比第一種危險得多,而只看成交量完全看不出來。

值得把這兩種情況跟 Day 10 接起來:它們其實是深度在成交端留下的影子。深度厚的時候,同樣的主動買賣量推不動價格,於是成交量高而波動低;深度薄的時候,一張小單就吃穿好幾檔,於是成交量低而波動高。掛單簿只有我們自己錄的那 45 分鐘,但「成交量與波動率的比值」在完整的歷史上都算得出來——它是流動性的一個間接代理,而且不必掛單簿資料。Day 13 的假突破判斷會用到這個關係。

ATR:帶跳空的波動幅度

ATR(Average True Range,平均真實區間)是另一種波動率,用價格單位表示而不是百分比。

它的核心是「真實區間」:

TR = max(高 − 低, |高 − 前收|, |低 − 前收|)

第一項是這根 K 線自己的範圍。後兩項處理的是跳空:如果這一根整根跳到前一根之上,「高 − 低」可能很小,但實際的價格移動很大。

換句話說,真實區間問的不是「這根 K 線畫出來有多長」,而是「從上一次收盤到現在,價格實際走過的範圍至少有多大」。這個定義把 K 線之間的斷點也算進去,所以它對「拿它決定停損」這個用途比單純的高低差合適——停損要防的是價格跑掉,而價格不會因為跨了一根 K 線就停下來。

加密貨幣 24/7 不休市,所以跳空比股市少得多——沒有隔夜、沒有週末休市。但快速行情裡一分鐘跳 1% 的事還是會發生,那時候只看「高 − 低」會低估波動。

ATR 之所以今天就要建起來,是因為 Day 24 決定停損距離時要用它:「停損放在 2 個 ATR 之外」是一個會隨市場波動自動伸縮的規則,而「停損放在 200 USDT 之外」不是。這個差別是整個系列在談「參數要無單位」的同一件事,只是換到了風險管理那一側。

為什麼 24/7 的市場還是有時段節奏

加密貨幣沒有開盤收盤,理論上任何時間都一樣。實際上完全不是。

實測 2025-01-01 到 2026-07-31 的 13,848 根 1 小時 K 線,把成交筆數按 UTC 鐘點分組、除以整體平均:

  • 最冷清:05:00 UTC,0.69 倍
  • 最熱鬧:14:00 UTC,1.88 倍
  • 兩者相差 2.71 倍(用未四捨五入的倍數相除;拿印出來的 1.88 ÷ 0.69 會得到 2.72)

14:00 UTC 是美國股市開盤前後(美東早上 9 到 10 點),05:00 UTC 是亞洲的清晨、歐洲的深夜。也就是說即使市場不休息,人會休息,而且參與者的地理分布留下了明顯的痕跡。

這種結構有一個名字叫季節性——一種可預測的、週期性的成分。而可預測的成分在特徵工程裡有一個固定的處理方式:把它除掉,而不是把它當訊號。

理由是這樣:如果一個特徵在下午兩點的值系統性地比凌晨五點高,那麼任何「這個特徵很高就進場」的策略,實際上都摻雜了一個「在下午兩點進場」的規則。策略回測出來的績效裡,有多少來自那個特徵、有多少只是來自時段,分不出來。把季節性除掉之後剩下的殘差,才是「相對於這個時段該有的樣子,現在不尋常的部分」。

所以基準怎麼取,是今天的核心問題。

標準化:z-score 到底在假設什麼

z-score 的形式很單純,但它背後有兩個假設,而市場資料兩個都不完全滿足。

第一個假設是「有一個穩定的平常」。 減掉平均、除以標準差,前提是那個平均與標準差描述的是同一個分布。市場不是——2025 年初的成交筆數水準跟 2026 年中不一樣,牛市與盤整期的活躍度基準也不一樣。這就是為什麼基準要滾動逐步展開,而不是拿整段樣本算一次:後者等於宣稱這一年半的市場是同一個分布。

第二個假設是「分布大致對稱、尾巴不厚」。 這個假設不成立的後果會在今天的實測數字上直接看到:z-score 超過 +2 的比例,如果是常態分布應該是 2.28%,實測是 5.86% 與 4.59%。活躍度的分布是右偏的——它有下限(成交筆數不能是負的)沒有上限,暴量可以是平常的一百倍,冷清最多就是趨近於零。

這一點跟 Day 11 講「標準差是一個尺度,不是一個機率」是同一句話。標準化真正買到的是可比性:不同交易對、不同時段的活躍度可以放在同一條軸上比較。它沒有買到機率解釋,所以「z > 2」是一個要回頭數次數才知道多罕見的門檻,不是查表就有答案的東西。

知道這兩個假設不成立,還是可以用 z-score,只是要知道它降級成什麼:它是一個粗略的排名代理,而不是一個有機率意義的統計量。

基準的粒度是一個取捨

既然要跟「平常」比,「平常」的範圍就是一個要決定的事,而它有很多層可選:

  • 最近 N 根比(滾動基準)。
  • 同一個鐘點的歷史比。
  • 同一個星期幾的同一個鐘點比。
  • 再細下去:同一個月份、同一種市場狀態……

直覺會想:越細越準,因為比較的對象越像。但這裡有一個硬性的代價——基準越細,可用的樣本越少。

一年半的 1 小時資料有 13,848 根。切成 24 個鐘點,每個鐘點有 577 個樣本,還算充足。再切星期幾,每格只剩 82 個。再往下切月份就只剩個位數,那時候算出來的「平常」本身就非常不穩,z-score 的分母抖動會比分子還大。

這是估計上的一個標準取捨:粗的基準有系統性偏差(它把不同時段混在一起),細的基準有估計誤差(樣本太少)。沒有一個普遍正確的答案,只有「這份資料撐得住多細」這個問題,而它是可以用樣本數算出來的。

今天實作兩種(滾動與鐘點),理由是它們回答不同的問題而不是一個比另一個好:

  • 滾動基準說的是「跟最近這陣子比,現在熱不熱」。它會自動跟上市場水準的變化,代價是它把時段節奏也吸收進去了——凌晨五點的滾動基準裡混著下午兩點的資料。
  • 鐘點基準說的是「跟這個時段該有的樣子比,現在不尋常嗎」。它把季節性除掉了,代價是它要更長的暖機期,而且對市場水準的長期變化反應比較慢。

星期 × 鐘點那一層今天只拿來畫圖,不拿來當基準——82 個樣本畫熱力圖夠了,當 z-score 的分母不夠。

三種會讓回測變好看的資訊洩漏

今天要處理的兩個陷阱,本質上是同一類問題的兩個實例,值得先把這一類問題的形狀講清楚。

資訊洩漏指的是:計算某個時點的特徵時,用到了那個時點還不該知道的資訊。它有三種常見的形態,嚴重程度遞減、隱蔽程度遞增:

一、直接用未來的值。 最明顯的一種,Day 04 講交叉訊號時處理過,Day 10 驗證預測力時也再三強調 shift(-1) 只能出現在報酬那一側。這種錯誤通常會讓結果好到不合理,反而容易被抓到。

二、把當下混進歷史基準。 當根的成交筆數在收盤時確實已經知道了,所以嚴格說它不是「未來」。問題在於它被放進了「歷史基準」這個角色,而基準的定義應該是「在這根之前,正常的樣子」。混進當根之後,那個基準就不再是一個獨立的參考點——極端值會自己把自己的門檻抬高。

三、用整段樣本估參數。 這一種最隱蔽,因為程式碼裡完全看不到 shift(-1) 這種明顯的往前看。它長成一個很自然的 groupby(...).transform("mean"),或是一次算完整段的平均與標準差再拿去標準化。可是「整段」包含了未來,所以 2026-03-01 那天的基準裡含著 2026-08 的資料。

第三種在回測上騙人的程度最高,因為它的偏差方向很微妙:它讓極端事件變得不極端。 一次真實的暴量會把自己算進標準差裡,於是它的分數被壓下來;而所有平凡的時點因為基準被那次暴量撐大,分數也被壓下來一點。整體看起來就是「這個特徵很穩定,沒有離譜的值」——這正是拿去畫圖時最讓人放心的樣子。

今天的兩個陷阱分別是第二種與第三種。它們的共同點:寫錯不會報錯,畫出來的圖很正常,而且回測結果會比實際好。

工程實作

今天的程式要做什麼

今天有兩個互不相干的東西要算:活躍度是「比平常熱多少」,ATR 是「價格實際走了多少」。前者佔掉大半篇幅,因為它有兩個會安靜出錯的地方。

活躍度:

  1. 取出要量的那一欄——成交筆數、成交量、或這一根的絕對報酬,三個角度各量不同的東西,但後面的流程完全一樣。
  2. 算「平常」——這是今天的重點。滾動基準是「最近 N 根的平均與標準差」,而視窗必須排除當根;鐘點基準是「同一個鐘點的歷史平均與標準差」,而它只能用過去的同鐘點資料,不能拿整段樣本算一次。兩個地方寫錯都不會報錯。
  3. 算 z-score——現在的值減掉平常,除以平常的標準差。標準差是 0 的時候回 NaN。
  4. 算時段節奏表——把成交筆數按 UTC 鐘點分組取平均、再除以整體平均,得到每個鐘點的倍數;同一份計算再切成星期 × 鐘點的二維表,供畫圖用。

ATR:

  1. 算真實區間——這一根的高低差、以及高低各自跟前一根收盤價的距離,三個取最大。第一根沒有前收,所以留白而不是拿高低差充數。
  2. 平滑成 ATR——用 Wilder 平滑(跟 Day 06 的 RSI 同一套),得到一個以價格為單位的波動幅度。

收尾:

  1. 印報表——時段節奏的最冷與最熱、兩種基準下最後一根的分數與「超過 +2 的比例」、以及 ATR 和它佔收盤價的百分比。
  2. 畫熱力圖——橫軸鐘點、縱軸星期幾,格子放倍數。

第 4 步那張倍數表要能單獨取用,因為報表印出來的「最冷清 05:00、最熱鬧 14:00」跟圖上的顏色必須來自同一份計算,否則會出現「圖上是這樣、文字寫那樣」的落差。

三個角度,各自量不同的東西

「活躍」不是一個量,是三個。專案裡它是一個值(ActivityMeasure)而不是三個特徵類別,因為它們的計算流程完全相同(取一欄、算 z-score),差別只在取哪一欄——跟 Day 11 的 PriceSource 是同一個模式。

  • 成交筆數:衡量參與者的數量與急迫程度,跟金額無關;一筆 100 BTC 的大單只算一筆。
  • 成交量:衡量換手的規模,少數幾張大單就能讓它很高。
  • 絕對報酬(這一根的對數報酬取絕對值):衡量價格實際走了多少,跟成交多少無關。

前面「波動率與成交量會分家」那一節說的就是第三個角度與前兩個的差別。三個一起看才完整,Day 09 那兩根 K 線是最好的例子:成交量幾乎一樣,成交筆數差 4.2 倍。

取欄位時有一個型別上的小麻煩:Day 02 把 trade_count 訂成可空的 Int64(理由是 REST 那條路徑不提供這一欄),而可空整數不能直接參與浮點運算。所以要先過 Float64 再到 float64,中間那一步是為了讓 NA 變成 NaN 而不是丟例外。

陷阱一:當根不能算進自己的基準

z-score 的分子是「現在 − 平常」。直覺的寫法是這樣:

history = values.rolling(window)
z = (values - history.mean()) / history.std()

rolling(window) 包含當根,所以這就是前一節說的第二種洩漏。一根暴量的 K 線會被算進自己的「平常」裡,把平均與標準差都拉高,於是它自己的 z-score 被系統性低估。

正確的寫法是先 shift(1)

# quantbot/domain/features/trading_activity.py
    def _rolling_z_score(self, values: pd.Series) -> pd.Series:
        """跟最近 window 根比。

        **視窗要排除當根**:shift(1) 之後再 rolling。不排除的話,當根自己會被算進
        「平常」的平均與標準差裡,於是一根暴量的 K 線會自己把基準拉高,z-score
        因此被系統性低估。這是未來函數的近親——不是偷看未來,是把當下混進歷史。
        """
        history = values.shift(1).rolling(self.window)
        deviation = history.std()
        return (values - history.mean()) / deviation.where(deviation > 0)

差多少可以測:用一段平穩的資料加最後一根三倍暴量,排除當根的分數是含當根的兩倍以上。這個差距的方向永遠一致(含當根一定低估),所以它不是雜訊,是偏差。

順帶提一件測試資料的事,因為它是實際踩到的。第一版的測試資料用完全固定的序列([1000] * 30),結果全部失敗——標準差是 0,z-score 沒有定義。測試資料要有一點自然的起伏才測得到真的東西:一個完美平穩的市場不存在,拿它當測試資料,測到的是一個永遠不會發生的情況。

陷阱二:鐘點基準寫成一行就是未來函數

前面量到時段節奏差 2.71 倍,所以「跟同一個鐘點比」是必要的。這件事寫成一行非常誘人:

z = (values - values.groupby(hour).transform("mean")) / values.groupby(hour).transform("std")

這正是第三種洩漏。transform("mean") 用的是整段樣本的同鐘點平均,所以在 2026-03-01 那一天,鐘點 3 的基準裡含著 2026-08 的資料。

正確的寫法是每個鐘點各自往前累積自己的歷史:

# quantbot/domain/features/trading_activity.py
    def _hour_of_day_z_score(self, values: pd.Series) -> pd.Series:
        """跟同一個鐘點的歷史比,而且只用**過去**的同鐘點資料。

        直覺的寫法是 values.groupby(hour).transform("mean"),一行就好。但那個平均
        用了整段樣本,包含未來——在 2026-03-01 那天,基準裡含著 2026-08 的資料。
        回測時這種寫法會讓活躍度過濾條件看起來特別靈,因為它知道後面會發生什麼。

        正確的寫法是 shift(1) 之後 expanding():每個鐘點各自往前累積自己的歷史。
        代價是前面幾天沒有值(每個鐘點都要先看過至少兩次),這是應該付的代價。
        """
        hour = pd.Series(pd.DatetimeIndex(values.index).hour, index=values.index)
        grouped = values.groupby(hour)
        history = grouped.shift(1)
        mean = history.groupby(hour).expanding().mean().reset_index(level=0, drop=True)
        deviation = (
            history.groupby(hour).expanding().std().reset_index(level=0, drop=True)
        )
        return (values - mean) / deviation.where(deviation > 0)

groupby(hour).shift(1) 是在每個鐘點的組內往後挪一格,所以鐘點 3 的第 n 天看到的是鐘點 3 的第 n−1 天,而不是前一個小時。expanding() 再往前累積全部歷史。最後的 reset_index(level=0, drop=True) 是為了把 groupby().expanding() 多加出來的那一層索引拿掉,讓結果對得回原本的時間索引。

錯誤的版本會錯多少?做一個極端但乾淨的實驗:20 天的每小時資料,鐘點 3 一直很冷清(100 筆上下),最後一天的鐘點 3 突然暴量到 10,000。

  • 正確版本:z-score 超過 100。對「過去 19 天的鐘點 3 都在 100 筆上下」來說,10,000 筆是天文數字,這個分數是對的。
  • 一行版本:z-score 小於 5。因為那個 10,000 自己被算進了鐘點 3 的標準差裡,把分母撐得非常大。

一個真實發生的 100 倍暴量,被壓成「有點不尋常」。這正是前面說的「它讓極端事件變得不極端」——而且越極端的事件被壓得越厲害,所以看圖的時候會覺得這個特徵蠻穩定的。

暖機期沒辦法用一個數字表達

Feature 協定要求每個特徵回答 warmup_bar_count。滾動基準很好答(一個視窗);鐘點基準答不出來。

原因是暖機期在這裡不是根數的問題,是次數的問題:每個鐘點各自需要看過 window 次,而一天只出現一次某個鐘點,所以真正的暖機是 window 天。要換算成根數就得知道「一天幾根」,而那取決於 timeframe,特徵類別不知道。

處理方式是回一個誠實的下限(window),真正的暖機期由「第一個非 NaN 出現在哪裡」決定。這是介面設計上的一個真實限制,寫出來比硬湊一個數字好。Day 15 的管線因此不能只信這個數字——它要同時用 NaN 來切,而那個設計就是從今天這個限制長出來的。

實測 5 天的每小時資料:鐘點基準的前 48 根(前兩天)全是 NaN,因為每個鐘點都要先出現至少兩次才有標準差。

ATR:它為什麼不是 Indicator

Day 04 訂的 Indicator 是一個 ABC,compute() 會先把 self.column 那一欄取出來、轉成 float64,再交給子類別的 _compute()。SMA、EMA、RSI 三個都只吃一欄(收盤價),所以那個契約很合身。

ATR 吃三欄。硬要塞進 Indicator 只有兩條路:破壞它的契約(讓 _compute 收整張表),或在 ATR 裡繞過基底類別自己去拿資料。前者會影響三個已經寫好的指標,後者讓繼承變成裝飾。所以它是 Feature

這個判斷的依據不是「哪個分類比較漂亮」,而是「哪一個不用改已經寫好的東西」。Day 10 訂 Feature 的時候特別提過 Indicator 是 ABC(有共用實作)而 Feature 是 Protocol(沒有),今天就是那個區分第一次派上用場:ATR 跟 OBI 之間沒有一行共用實作,所以 Protocol 是對的。

平滑則直接重用 Day 06 為 RSI 寫的 WilderSmoother。Wilder 在同一篇文章裡提出 RSI 與 ATR,兩者用的是同一種平滑(α = 1/n),所以這裡 import 那個類別而不是再寫一份 ewm(alpha=1/period, adjust=False)。Day 06 花了一整節說明那一行有多容易寫錯(α 寫成 2/(n+1) 的話中位數差 4.94 個 RSI 點),複製它等於把那個陷阱複製一份。

真實區間的第一筆是 NaN,不是「高 − 低」

# quantbot/domain/features/average_true_range.py
    @staticmethod
    def true_range(candles: pd.DataFrame) -> pd.Series:
        """三個候選取最大值,全部向量化。

        第 0 筆是 NaN 而不是「高 − 低」:沒有前一根的收盤價,所以那兩個跳空項
        沒有定義。填成「高 − 低」會讓第一根的 TR 系統性偏小,而那個偏差會被
        Wilder 平滑一路帶進暖機期。WilderSmoother 的輸入慣例正是第 0 筆為 NaN。
        """
        previous_close = candles["close"].shift(1)
        candidates = pd.concat(
            {
                "high_low": candles["high"] - candles["low"],
                "high_close": (candles["high"] - previous_close).abs(),
                "low_close": (candles["low"] - previous_close).abs(),
            },
            axis=1,
        )
        # skipna=False 是必要的:第 0 筆只有 high_low 有值,用預設的 skipna=True
        # 會挑出那個值,於是第 0 筆變成「高 − 低」而不是 NaN
        return candidates.max(axis=1, skipna=False).rename("true_range")

skipna=False 那一行是這段最容易漏的地方。max(axis=1) 預設會跳過 NaN,所以第 0 筆會挑出唯一有值的 high_low,於是第一筆 TR 變成「高 − 低」——一個系統性偏小的值,而它會被 Wilder 平滑一路帶進整個暖機期。

不少現成的 ATR 實作就是這樣寫的,所以跨實作對數字時暖機期會對不上。這跟 Day 06 發現 pandas-ta 的 RSI 在暖機期跟教科書定義不一致是同一類問題:遞迴指標的起始值選擇會影響前面幾百根。

驗證方式沿用 Day 05、Day 06 建立的習慣:照 Wilder 的定義寫一份逐根迴圈的對照組(只在測試裡用,NEVER 進正式路徑),600 根隨機序列上兩者誤差在 1e-9 以內。

還有一件事要寫成測試而不是註解,因為 Day 24 要靠它:ATR 的單位是價格,不是百分比。 兩段形狀相同、價位差一千倍的資料,ATR 也差一千倍。所以「停損放在 2 個 ATR 之外」是一個可以跨交易對用的規則,而「ATR 大於 300 就不進場」不是——後者在 BTC 上是一個門檻,在 ETH 上是一道永遠不會被觸發的牆。

實際跑一次

今天用的是 1 小時線、跨一年半,粒度與範圍都跟前面幾天不一樣,所以先把資料補進來。Day 03 那支回補指令換個 --timeframe 就能用,--store 讓它除了落地 parquet 也寫進 candles

uv run python -m quantbot.entrypoints.backfill_command \
    --symbol BTC/USDT --market spot --timeframe 1h \
    --start 2025-01-01 --end 2026-08-01 --store
13848 根,缺 0 根,覆蓋率 100.0000%
寫入 candles:新增 13848 列

「缺 0 根、覆蓋率 100%」是 Day 08 那套完整性檢查順手給的,而今天特別值得看一眼:時段節奏那張表是按鐘點分組取平均,某個鐘點少了幾根,那一格的倍數就會偏掉,而它不會報錯,只會給出一個看起來很正常的數字。已經在跑 Day 08 那條每天自己跑的 pipeline 的話,這一步會顯示「新增 0 列」,那也是對的——代表資料早就補齊了。

資料備妥之後:

uv run python -m quantbot.entrypoints.activity_command \
    --symbol BTC/USDT --market spot --timeframe 1h \
    --start 2025-01-01 --end 2026-08-01
13,848 根 K 線:2025-01-01 00:00:00+00:00 → 2026-07-31 23:00:00+00:00

成交筆數的時段節奏(相對於整體平均)
  最冷清:05:00 UTC,0.69 倍
  最熱鬧:14:00 UTC,1.88 倍
  最熱與最冷相差 2.71 倍

同一個絕對值在不同基準下的分數(最後一根)
  rolling(168):-0.24(可用 13,680 根,超過 +2 的有 5.86%)
  hour_of_day:-0.37(可用 13,800 根,超過 +2 的有 4.59%)

ATR(14):293.05 USDT(收盤價的 0.47%)

那兩個「可用根數」就是暖機期的樣子,而它們不一樣長:滾動基準要等視窗滿(預設 168 根,一週),所以少了 168 根;鐘點基準是每個鐘點各自累積歷史,每個鐘點都要先出現幾次才有標準差,24 個鐘點加起來少了 48 根。報表把可用根數印出來,就是因為它跟宣告的暖機期不一定一樣,而那個差別 Day 15 的管線要處理。

(另外兩件小事:2.71 倍是用未四捨五入的倍數算的,拿印出來的 1.88 除以 0.69 會得到 2.72;ATR 那個 0.47% 是拿 293.05 對照那一段最後的收盤價 62,920.68。)

幾件事值得讀。

時段節奏是真的,而且不小。 2.71 倍不是統計上的細微差異——凌晨五點的正常成交筆數,在下午兩點會被當成極度冷清。任何用絕對門檻寫的活躍度條件(「成交筆數超過 5,000 才進場」)在這種結構下等於一個時段過濾器,而寫的人可能沒有意識到。

兩種基準給出不同的答案。 最後一根在滾動基準下是 −0.24,在鐘點基準下是 −0.37。兩個都對,它們回答的是不同的問題:滾動基準說「比最近一週稍微冷」,鐘點基準說「比同一個鐘點的歷史更冷一些」。前面那節的取捨在這裡有了具體的數字——選哪一個不是精確度的問題,是問題本身的問題。

觸發頻率也不同:超過 +2 的比例是 5.86% 對 4.59%。這正是前面說的第二個假設不成立的證據:常態分布會給 2.28%,實測是兩倍以上。所以「z-score > 2」不是「百分之二的罕見事件」,實測是百分之五到六,而這件事會直接改變一個以它為條件的策略的交易次數。

ATR 是收盤價的 0.47%。 1 小時線上,BTC 的平均真實區間大約是價格的半個百分點。這個數字在 Day 24 會直接變成停損距離:2 個 ATR 大約是 1%。

視覺化:星期 × 鐘點的熱力圖

時段節奏有兩個週期疊在一起——一天之內的鐘點節奏,一週之內的星期節奏。折線圖只能表達一個,另一個會被攤平,所以用熱力圖:橫軸鐘點、縱軸星期幾,格子裡放「該時段的平均值除以整體平均」。

放倍數而不是原始值,是為了讓這張圖換一個交易對還能用——原始值的色階會被幣價綁住,倍數是無單位的。這跟前面幾天反覆在做的標準化是同一個動作,只是這次是為了讓圖可重用。

那張倍數表在專案裡是一個可以單獨取用、單獨測試的方法,不是埋在畫圖流程裡的中間變數。前面那支指令印出的「最冷清 05:00、最熱鬧 14:00」就是從這張表算的——圖與數字用的是同一份計算,不會出現「圖上看起來是這樣、文字寫的是那樣」的落差。

打開產出的 html,會看到週末整片偏冷、平日下午整片偏熱,而 14:00 那一欄從週一到週五都是最深的紅色。這張圖沒有交易訊號,它的用途是讓「時段節奏」從一句話變成一個看得到的結構,之後把它寫進過濾條件時才知道自己在濾什麼。

今日交付物

quantbot/
├── domain/
│   ├── values/
│   │   ├── activity_measure.py                 今天:三個角度
│   │   └── activity_baseline.py                今天:ROLLING / HOUR_OF_DAY
│   └── features/
│       ├── trading_activity.py                 今天:z-score
│       └── average_true_range.py               今天:ATR(是 Feature 不是 Indicator)
├── infrastructure/charting/
│   └── plotly_activity_heatmap_renderer.py     今天
├── entrypoints/activity_command.py             今天
└── tests/domain/features/
    ├── test_trading_activity.py                今天
    └── test_average_true_range.py              今天

驗收標準

七項全過才算完成:

  1. uv run pytest tests/domain/features/test_trading_activity.py 全綠,包含「排除當根」與「鐘點基準只用過去」兩個測試。這兩個是今天的重點,它們守的都是不會報錯的錯誤。排除當根那一個要斷言正確版本的分數是含當根版本的兩倍以上。
  2. 鐘點基準那個測試要看到具體的落差:正確版本的 z-score 超過 100,一行 transform("mean") 版本小於 5。
  3. uv run pytest tests/domain/features/test_average_true_range.py 全綠,含真實區間第一筆是 NaN、跳空被算進去、與迴圈版對照組誤差小於 1e-9、ATR 是價格單位(價位差一千倍則 ATR 差一千倍)四項。
  4. 先用 backfill_command 把 1 小時線補到庫裡(--timeframe 1h --start 2025-01-01 --end 2026-08-01 --store),確認缺 0 根;再跑 uv run python -m quantbot.entrypoints.activity_command --symbol BTC/USDT --market spot --timeframe 1h --start 2025-01-01 --end 2026-08-01,印出時段節奏(最冷清與最熱鬧相差 2.7 倍上下)、兩種基準的分數與觸發比例、以及 ATR。缺漏必須是 0——少幾根 K 線不會讓這支指令失敗,只會讓那幾個鐘點的倍數偏掉。
  5. 兩種基準的「超過 +2 的比例」都明顯高於常態分布的 2.28%。這是活躍度厚尾的證據,不是計算錯誤。
  6. 打開 notebooks/day12-spot_BTCUSDT_1h-activity.html:週末整片偏冷、平日 14:00 那一欄最深,色階以 1.0 為中心。
  7. uv run mypy quantbotuv run lint-imports 全過。

第 2 項是今天最有價值的一項。那兩個數字(100 對 5)是同一份資料、同一個問題、兩種寫法,而錯的那種寫法只有一行、看起來更乾淨。

免責聲明:本文為程式與資料工程的技術分享,所有數字皆為教學範例,不構成投資建議;活躍度與 ATR 都是對已發生行情的描述,不預測後續走勢。

明天

到今天為止的五個東西(三個指標、OBI、VWAP、活躍度、ATR)都是時間序列特徵:每一根 K 線都有一個值,值的意義是連續的。

明天要做的是不一樣的形狀:事件

假突破是新手最常虧錢的地方之一——價格衝破前高,看起來要走了,然後馬上被打回來。「衝破前高」不是一個連續的量,它是一個在某幾根 K 線上發生、其餘時候不發生的事件。而事件式特徵有一個時間序列特徵沒有的陷阱:「前高」這個東西非常容易在計算時把當根算進去,也就是今天那個第二種洩漏,只是這次它藏在 rolling().max() 裡。

Day 13 會定義一個真假突破的判斷,並用歷史資料統計兩者的比例與特徵分布。也會示範事件式特徵怎麼跟時間序列特徵接在同一張表上——那是 Day 15 的管線要處理的最後一種形狀。

Reference


上一篇
Day 11:均價要用哪個?VWAP 與成交量加權的資金流視角"
系列文
量化交易入門:從 K 線到可組合的交易策略引擎13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言